﻿// project      : soapi-notify 
// purpose      : console and growl notifications of new Stack Exchange questions
// source       : http://soapi.info/tools/notify.aspx
// announcement : http://stackapps.com/questions/820
// website      : http://soapi.info/tools/notify.aspx
// license      : MIT/X11 - Copyright (c) 2010 Sky Sanders - See License.txt
// inspiration  : http://stackapps.com/questions/817/senotify-se-new-question-notification
// 
//  
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Web;
using System.Web.Script.Serialization;
using System.Windows.Forms;
using Growl.Connector;
using NDesk.Options;
using Soapi.Notify.Properties;
using Application = Growl.Connector.Application;
using Timer = System.Threading.Timer;

namespace Soapi.Notify
{
    internal class Program : ApplicationContext
    {
        private const string UpdateUrlFormat = "http://api.{0}.com/1.0/questions/{1}?key={2}";
        private const int PageSize = 100;
        private const int UpdateInterval = 60;
        private const int PollingInterval = 60;
        private const int MinimumPollingInterval = PollingInterval;
        private const int MaxBatchIdComponentLength = 100;
        public const string ApiKey = "pg8tJF_B9kyaBuCGyPfGUQ";
        public const string ApplicationName = "soapi-notify";
        private readonly UpdateCache _updateCache;
        //private readonly NameValueCollection2 _updateRegistry;
        private Application _growlApplication;

        private GrowlConnector _growlConnector;
        private NotificationType _growlNotificationType;
        private bool _hidden;
        private NotifyIcon _notificationIcon;
        private Timer _updateTimer;


        public Program(IEnumerable<string> args)
        {
            #region Options, Flags and Defaults

            bool showUsageAndExit = false;
            bool useGrowl = false;
            int refresh = PollingInterval;
            string sites = "stackoverflow";
            string tags = string.Empty;
            bool excludeTags = false;
            bool update = false;
            var options = new OptionSet
                              {
                                  {
                                      "sites|s=",
                                      "Sites to monitor, semi-colon delimited. possible values: serverfault;stackoverflow;superuser;meta.stackoverflow;stackapps"
                                      ,
                                      v => sites = v
                                      },
                                  {
                                      "tags|t=",
                                      "If specified, only questions containing these tags will be notified. semi-colon delimited. example: c#;sqlite\r\n"
                                      ,
                                      v => tags = v
                                      },
                                  {
                                      "exclude|x",
                                      "Exclude questions containing tags specified in tags|t",
                                      v => excludeTags = v != null
                                      },
                                  {
                                      "poll|p=",
                                      "Polling interval in seconds, default to 60 seconds. Minimum value is 60.",
                                      v => refresh = Convert.ToInt32(v)
                                      },
                                  {
                                      "growl|g",
                                      "Enable growl notification (need growl for windows)",
                                      v => useGrowl = v != null
                                      },
                                  {
                                      "update|u",
                                      "Update pinned questions. Note: this option requires Growl For Windows and the use of one of the soapi-notify displays."
                                      ,
                                      v => update = v != null
                                      },
                                  {
                                      "help|h|?",
                                      "Show usage.",
                                      v => showUsageAndExit = v != null
                                      },
                              };

            #endregion

            string[] siteList;

            try
            {
                var unrecognized = options.Parse(args);

                if (unrecognized.Count > 0)
                {
                    string errMessage = string.Format("unrecognized arguments - {0}",
                                                      string.Join(", ", unrecognized.ToArray()));

                    throw new ArgumentException(errMessage);
                }


                if (showUsageAndExit)
                {
                    throw new Exception("");
                }

                // 
                if (refresh < MinimumPollingInterval)
                {
                    refresh = MinimumPollingInterval;
                }
                if (update)
                {
                    // offset the updates from poll and off of any common number for fade out
                    _updateTimer = new Timer(Update, null, (UpdateInterval * 1000) + 7000, UpdateInterval * 1000);
                    _updateCache = new UpdateCache();
                }

                siteList = sites.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine("soapi-notify: {0}\r\n", ex.Message);
                options.WriteOptionDescriptions(Console.Error);


                // this is not working. we are not a forms apps.
                // we are a console app with a bolted on forms app carbeurator
                // so, we will just return - need to abstract this and ask SO

                // user must term the app explicitly until this is fixed
                Console.Error.WriteLine("\r\nPress CTRL-C to exit.");

                System.Windows.Forms.Application.Exit();
                return;
            }

            InitializatNotificationIcon();

            if (useGrowl)
            {
                InitializeGrowl();
            }


            try
            {
                // backdate -refresh to prime the pump and give some immediate gratification
                DateTime lastDate = DateTime.Now.AddSeconds(-refresh);

                while (true)
                {
                    try
                    {
                        // mark the time
                        DateTime mark = lastDate;
                        // bump just a tiny bit to eliminate duplicates
                        lastDate = DateTime.Now.AddMilliseconds(100);

                        var questions = new List<Question>();
                        foreach (string site in siteList)
                        {
                            questions.AddRange(FetchNewQuestions(site, mark, tags, excludeTags).questions);
                        }

                        questions.Sort((a, b) => a.creation_date.CompareTo(b.creation_date));

                        foreach (Question question in questions)
                        {
                            WriteQuestion(question);

                            if (useGrowl)
                            {
                                // send notification and register the question for updates
                                GrowlQuestion(question, false);
                                if (update)
                                {
                                    _updateCache.Add(question.site, question.question_id.ToString());
                                }
                            }
                        }
                    }
                    catch (Exception ex)
                    {
                        string errMessage = string.Format("There was an error retrieving results.\r\n{0}", ex.Message);
                        throw new Exception(errMessage);
                    }
                    DoGameLoop(refresh);
                }
            }
            catch (Exception ex)
            {
                string errMessage = string.Format("soapi-notify: {0}\r\n", ex.Message);
                Console.Error.WriteLine(errMessage);
                // TODO: having trouble getting this console/foms frankenstien app to close.
                // user must ctrl-c to exit.
                Console.Error.WriteLine("\r\nPress CTRL-C to exit.");
            }
        }

        private void Update(object ignored)
        {
            List<KeyValuePair<string, string>> updateLookup;
            lock (_updateCache)
            {
                updateLookup = _updateCache.GetUpdateList(MaxBatchIdComponentLength);
            }
            foreach (KeyValuePair<string, string> kvp in updateLookup)
            {
                string batchUrl = string.Format(UpdateUrlFormat, kvp.Key, kvp.Value, ApiKey);
                List<Question> questions = GetQuestionsResponse(batchUrl).questions;
                string site = kvp.Key;
                questions.ForEach(q => q.site = site);
                questions.ForEach(q => GrowlQuestion(q, true));
            }
        }

        private static void Main(string[] args)
        {
            if (args.Length == 0)
            {
                var gui = new GuiForm();
                DialogResult result = gui.ShowDialog();
                if (result != DialogResult.OK)
                {
                    return;
                }

                args = gui.CommandLineTextBox.Text.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            }
            System.Windows.Forms.Application.Run(new Program(args));
        }

        private void DoGameLoop(int refresh)
        {
            var interval = DateTime.Now.AddSeconds(refresh).Ticks;

            while (DateTime.Now.Ticks < interval)
            {
                Thread.Sleep(250);
                System.Windows.Forms.Application.DoEvents();
                if (Console.KeyAvailable)
                {
                    ConsoleKeyInfo cki = Console.ReadKey(true);
                    if (cki.Key == ConsoleKey.Spacebar)
                    {
                        if (_hidden)
                        {
                            ShowConsole();
                        }
                        else
                        {
                            HideConsole();
                        }
                    }
                }
            }
        }

        private static QuestionsResponse FetchNewQuestions(string site, DateTime lastDate, string onlyTags,
                                                           bool excludeTags)
        {
            long minDate = lastDate.ToUniversalTime().ToUnixTime();

            long maxDate = DateTime.Now.ToUniversalTime().ToUnixTime();
            string encodedTags = HttpUtility.UrlEncode(onlyTags ?? "");


            // depending on whether we are filtering on specific tags, we use /questions or /search

            string urlFormat = string.IsNullOrEmpty(onlyTags)
                                   ? RequestFormatAll
                                   : excludeTags ? RequestFormatNotTagged : RequestFormatTagged;
            string url = string.Format(urlFormat, site, minDate, maxDate, ApiKey, encodedTags);


            QuestionsResponse questionsResponse = GetQuestionsResponse(url);


            questionsResponse.questions.ForEach(q => q.site = site);
            return questionsResponse;
        }


        private void InitializeGrowl()
        {

            if(!Installer.GrowlInstalled)
            {
                throw new Exception("Growl is not installed");
            }
            if(!Installer.DisplaysCurrent || !Installer.DisplaysInstalled)
            {
                if(Installer.GrowlIsRunning)
                {
                    throw new Exception("Cannot install or update displays while growl is running.");
                }

                Installer.InstallDisplays();
            }
            

            // setup growl
            _growlNotificationType = new NotificationType(ApplicationName, ApplicationName,
                                                          Resources.logic_or.ToBitmap(), true) { DisplayName = "soapi-notify" };

            _growlConnector = new GrowlConnector
                                  {
                                      EncryptionAlgorithm = Cryptography.SymmetricAlgorithmType.PlainText
                                  };
            _growlConnector.NotificationCallback += NotificationCallback;
            _growlApplication = new Application(ApplicationName);
            _growlConnector.Register(_growlApplication, new[] { _growlNotificationType });
        }


        /// <summary>
        ///   We just want to remove the closed 
        ///   notification's question from the update registry
        /// </summary>
        private void NotificationCallback(Response ignored, CallbackData data)
        {
            if (_updateCache == null)
            {
                return;
            }
            string site = data.Data;
            string questionId = data.Type;

            lock (_updateCache)
            {
                _updateCache.Remove(site, questionId);
            }
        }


        public void GrowlQuestion(Question question, bool update)
        {
            var sbTitle = new StringBuilder();
            sbTitle.AppendFormat("({0}|{1}{2}|{3}) {4}", question.score, question.answer_count,
                                 question.accepted_answer_id == 0 ? "" : "+", question.view_count,
                                 question.title);

            var sbText = new StringBuilder();
            sbText.Append(ShortPastDateFormat(question.creation_date.FromUnixTime()) + " ");
            foreach (string tag in question.tags)
            {
                sbText.AppendFormat("[{0}] ", tag);
            }
            Bitmap icon = Resources.so;
            switch (question.site.ToUpper())
            {
                case "STACKOVERFLOW":
                    icon = Resources.so;
                    break;
                case "META.STACKOVERFLOW":
                    icon = Resources.mso;
                    break;
                case "SUPERUSER":
                    icon = Resources.su;
                    break;
                case "SERVERFAULT":
                    icon = Resources.sf;
                    break;
                case "STACKAPPS":
                    icon = Resources.sa;
                    break;
            }

            //HACK: hardcoding the api/version  and site. these need to be pulled dynamically and/or cached

            string notificationId = String.Format("http://{0}.com/questions/{1}{2}", question.site, question.question_id,
                                                  update ? "?update" : "");


            var notification2 =
                new Notification(_growlApplication.Name, _growlNotificationType.Name,
                                 null, sbTitle.ToString(), sbText.ToString())
                    {
                        Icon = icon,
                        CoalescingID = notificationId
                    };

            var callback = new CallbackContext(question.site, question.question_id.ToString());
            // one last check to make sure that the update, if any, is still valid
            if (update && !_updateCache.Contains(question.site, question.question_id.ToString()))
            {
                return;
            }
            _growlConnector.Notify(notification2, callback);
        }

        public void WriteQuestion(Question question)
        {
            int width = Console.WindowWidth;

            Console.ForegroundColor = ConsoleColor.DarkBlue;
            Console.WriteLine(new string('-', width - 1));
            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.Write("(");
            Console.ForegroundColor = ConsoleColor.Gray;
            Console.Write(question.score);
            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.Write("|");
            Console.ForegroundColor = question.accepted_answer_id == 0 ? ConsoleColor.Red : ConsoleColor.Green;
            Console.Write(question.answer_count);
            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.Write("|");
            Console.ForegroundColor = ConsoleColor.Gray;
            Console.Write(question.view_count);
            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.Write(")  ");
            Console.ForegroundColor = ConsoleColor.White;
            Console.Write(question.title);


            Console.WriteLine();

            Console.ForegroundColor = ConsoleColor.DarkCyan;
            Console.Write(" " + question.creation_date.FromUnixTime().ToLocalTime().ToShortTimeString());
            Console.Write(" ");

            foreach (string tag in question.tags)
            {
                Console.ForegroundColor = ConsoleColor.DarkGray;
                Console.Write("[");
                Console.ForegroundColor = ConsoleColor.DarkYellow;
                Console.Write(tag);
                Console.ForegroundColor = ConsoleColor.DarkGray;
                Console.Write("] ");
            }
            Console.Write(Environment.NewLine);

            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.WriteLine(string.Format(" http://{0}.com/questions/{1}", question.site, question.question_id));
        }

        public static QuestionsResponse GetQuestionsResponse(string url)
        {
            var request = (HttpWebRequest)WebRequest.Create(url);
            request.UserAgent = ApplicationName ;
            request.AutomaticDecompression = DecompressionMethods.GZip;

            string json;

            using (var jsonStream = new MemoryStream())
            using (WebResponse response = request.GetResponse())
            using (Stream responseStream = response.GetResponseStream())
            {
                var buffer = new byte[1024];

                int read;

                while ((read = responseStream.Read(buffer, 0, buffer.Length)) > 0)
                    jsonStream.Write(buffer, 0, read);

                json = Encoding.UTF8.GetString(jsonStream.ToArray()).Trim();
            }

            var questionsResponse = new JavaScriptSerializer().Deserialize<QuestionsResponse>(json);
            return questionsResponse;
        }


        public static string ShortPastDateFormat(DateTime dateTime)
        {
            TimeSpan diff = DateTime.Now.ToUniversalTime().Subtract(dateTime);
            double value;
            string unit;
            if (diff.TotalDays > 1)
            {
                value = diff.TotalDays;
                unit = "d";
            }
            else if (diff.TotalHours > 1)
            {
                value = diff.TotalHours;
                unit = "h";
            }
            else if (diff.TotalMinutes > 1)
            {
                value = diff.TotalMinutes;
                unit = "m";
            }
            else
            {
                value = diff.TotalSeconds;
                unit = "s";
            }
            return Math.Round(value, MidpointRounding.AwayFromZero) + unit + " ago";
        }

        #region Constants

        // urls built in 30 seconds with soapi-explore: http://soapi.info/tools/explore.aspx
        private static readonly string RequestFormatAll =
            "http://api.{0}.com/1.0/questions?&sort=creation&order=asc&pagesize=" + PageSize +
            "&page=1&min={1}&max={2}&key={3}";

        private static readonly string RequestFormatTagged =
            "http://api.{0}.com/1.0/search?tagged={4}&sort=creation&min={1}&max={2}&order=asc&pagesize=" + PageSize +
            "&page=1";

        private static readonly string RequestFormatNotTagged =
            "http://api.{0}.com/1.0/search?nottagged={4}&sort=creation&min={1}&max={2}&order=asc&pagesize=" + PageSize +
            "&page=1";

        #endregion

        #region Console Window Manipulation

        private const int ShowWindowHide = 0;
        private const int ShowWindowMaximize = 3;
        private const int ShowWindowMinimize = 6;
        private const int ShowWindowRestore = 9;
        private static readonly IntPtr ThisConsole = GetConsoleWindow();

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

        [DllImport("kernel32.dll", ExactSpelling = true)]
        private static extern IntPtr GetConsoleWindow();

        private void InitializatNotificationIcon()
        {
            // minimize to tray - this is what using the forms message pump (ApplicationContext) 
            // in a console app is all about.

            // the tray icon needs a pump. so now that we are a hybrid forms/console app there are a lot
            // of possibilities.

            _notificationIcon = new NotifyIcon
                                    {
                                        Icon = Resources.logic_or,
                                        Visible = true
                                    };

            _notificationIcon.Click += TrayClick;
            _notificationIcon.Visible = false;
        }

        private void ShowConsole()
        {
            _hidden = false;
            _notificationIcon.Visible = false;
            ShowWindow(ThisConsole, ShowWindowRestore);
        }

        private void HideConsole()
        {
            _hidden = true;
            _notificationIcon.Visible = true;
            _notificationIcon.ShowBalloonTip(15000, ApplicationName, "click to show", ToolTipIcon.Info);
            ShowWindow(ThisConsole, ShowWindowHide);
        }

        private void TrayClick(object sender, EventArgs e)
        {
            ShowConsole();
        }

        #endregion
    }
}